iT邦幫忙

2025 iThome 鐵人賽

DAY 10
0
Software Development

渲染與GPU編程系列 第 10

Day 9|數大便是美:GPU Instancing 實作(零基礎+Unity 範例)

  • 分享至 

  • xImage
  •  

你想在場景裡放 幾百、幾千 棵樹、石頭、子彈殼嗎?一個一個畫(Draw Call)會把 CPU 累死。
GPU Instancing 的想法超單純:同一個網格+同一個材質,一次下命令,請顯示卡自己「複製很多份」來畫;差異(顏色、大小、位置…)交給每一份「分身」各自帶的資料。
成果:Draw Call 大幅下降,CPU 輕鬆很多,FPS 更穩。


1) 直覺建立:Instancing 跟「一般畫很多次」差在哪?

  • **傳統:**每一個物件 → CPU 下 1 次 Draw Call → GPU 畫 1 次。1000 個物件要 1000 次呼叫
  • Instancing: 1 次 Draw Call 丟出「1000 份相同的網格+材質」,並附上 1000 份「差異資料」(例如 1000 個位置矩陣、1000 個顏色),GPU 自己一口氣畫完。

口訣:東西要長得一樣(mesh & material),只允許少量可變(per-instance)屬性。


2) 什麼能變?什麼不能變?

能變(per-instance)

  • 位置/旋轉/縮放(透過「每個實例的物件→世界矩陣」)
  • 少量數值(例如顏色、金屬度…)— 這些要在 shader 裡宣告成「Instanced Property」

不能變(共享)

  • Mesh(必須相同)
  • Material(必須同一個;材質貼圖不能一棵樹用 A,另一棵用 B)
  • 渲染狀態(Blend、ZWrite、Cull、Shader Keywords…)

要不同貼圖?常見做法:圖集(Atlas)或 Array Texture,然後用「每實例索引」挑片段;這算進階題,今天先不展開。


3) 開箱即用的一鍵法(最簡單)

  1. 場景裡放很多個 相同 Mesh(例如一堆 Cube)。
  2. 他們用「同一個材質」。
  3. 在材質 Inspector 勾選 Enable GPU Instancing
  4. 這個材質所用的 Shader 必須支援 Instancing(Unity 內建 Standard、URP Lit 多半都支援)。

成效可用 StatsFrame Debugger 觀察:Batches / SetPass Calls 應該會下降。
但自動合批條件很多(光照/陰影/Lightmap/關鍵字不同都會拆批),所以實務上我們常自己寫一個「保證支援 Instancing」的 Shader用程式一次繪製


4) 自己寫一個支援 Instancing 的 Unlit Shader(Built-in)

重點:使用 Unity 的 Instancing 宏,把「每實例顏色」宣告成 Instanced Property。
之後我們會用 Graphics.DrawMeshInstanced 一口氣畫上千個、每個顏色都不同。

Shader "CustomLearning/Unlit_InstancedColor"
{
    Properties
    {
        _BaseMap ("Base (sRGB)", 2D) = "white" {}
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" "Queue"="Geometry" }
        Pass
        {
            Cull Back
            ZWrite On
            Blend One Zero

            CGPROGRAM
            #pragma vertex   vert
            #pragma fragment frag
            #pragma target   3.0
            #pragma multi_compile_instancing       // ★ 開啟 Instancing 變體
            #include "UnityCG.cginc"

            sampler2D _BaseMap; float4 _BaseMap_ST;

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv     : TEXCOORD0;
                UNITY_VERTEX_INPUT_INSTANCE_ID      // ★ 頂點輸入帶 Instance ID
            };

            struct v2f
            {
                float4 pos : SV_POSITION;
                float2 uv  : TEXCOORD0;
                UNITY_VERTEX_INPUT_INSTANCE_ID      // ★ 透傳 Instance ID
            };

            // ★ 宣告每實例屬性:這裡用顏色
            UNITY_INSTANCING_BUFFER_START(Props)
                UNITY_DEFINE_INSTANCED_PROP(float4, _Color) // 每實例的 Tint
            UNITY_INSTANCING_BUFFER_END(Props)

            v2f vert (appdata v)
            {
                v2f o;
                UNITY_SETUP_INSTANCE_ID(v);
                UNITY_TRANSFER_INSTANCE_ID(v, o);          // 傳到 frag

                // 每實例的物件→世界矩陣由 Unity 自動提供
                float4 posWS = mul(unity_ObjectToWorld, v.vertex);
                o.pos = mul(UNITY_MATRIX_VP, posWS);
                o.uv  = TRANSFORM_TEX(v.uv, _BaseMap);
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                UNITY_SETUP_INSTANCE_ID(i);
                fixed4 tex = tex2D(_BaseMap, i.uv);
                float4 tint = UNITY_ACCESS_INSTANCED_PROP(Props, _Color); // ★ 取出每實例顏色
                return tex * tint;
            }
            ENDCG
        }
    }
}

你剛學到:

  • #pragma multi_compile_instancing 開啟 instancing 支援。
  • UNITY_INSTANCING_BUFFER 區塊裡用 UNITY_DEFINE_INSTANCED_PROP 宣告「每實例」變數。
  • UNITY_SETUP/TRANSFER/ACCESS_INSTANCE_ID 宏讓 shader 知道正在處理哪一份分身。
  • 變換使用 unity_ObjectToWorld / UNITY_MATRIX_VPUnity 會給每實例不同的矩陣

5) 一次畫一大堆:C# Graphics.DrawMeshInstanced

每次最多 1023 份(API 限制),要更多就分批呼叫。
我們會:建一個 2D 陣列的方格、每格一個 Matrix4x4 + 一個顏色,一個材質、幾個批次全畫出來。

using System.Collections.Generic;
using UnityEngine;

public class InstancingDemo : MonoBehaviour
{
    public Mesh mesh;                  // 指向 Cube, Sphere, 或你自己的 Mesh
    public Material instancedMaterial; // 使用上面那個 "Unlit_InstancedColor" Shader
    public int countPerAxis = 32;      // 32x32 = 1024 個
    public float spacing = 2f;

    const int BatchSize = 1023;        // API 限制
    readonly List<Matrix4x4[]> _matBatches = new();
    readonly List<MaterialPropertyBlock> _mpbBatches = new();

    void Start()
    {
        int total = countPerAxis * countPerAxis;
        int done = 0;

        while (done < total)
        {
            int n = Mathf.Min(BatchSize, total - done);
            var mats = new Matrix4x4[n];
            var colors = new Vector4[n];

            for (int i = 0; i < n; i++)
            {
                int idx = done + i;
                int x = idx % countPerAxis;
                int y = idx / countPerAxis;

                Vector3 pos = new(
                    (x - countPerAxis / 2f) * spacing,
                    0f,
                    (y - countPerAxis / 2f) * spacing);

                mats[i] = Matrix4x4.TRS(pos, Quaternion.identity, Vector3.one);

                Color c = Color.HSVToRGB((float)idx / total, 0.8f, 1f);
                colors[i] = new Vector4(c.r, c.g, c.b, 1f);
            }

            var mpb = new MaterialPropertyBlock();
            mpb.SetVectorArray("_Color", colors);  // ★ 把每實例顏色陣列塞進去
            _matBatches.Add(mats);
            _mpbBatches.Add(mpb);

            done += n;
        }
    }

    void Update()
    {
        for (int b = 0; b < _matBatches.Count; b++)
        {
            Graphics.DrawMeshInstanced(
                mesh,
                0,
                instancedMaterial,
                _matBatches[b],
                _matBatches[b].Length,
                _mpbBatches[b],
                UnityEngine.Rendering.ShadowCastingMode.Off, // 先關影子,之後再試
                false,
                0, null,
                UnityEngine.Rendering.LightProbeUsage.Off);
        }
    }
}

成果:
result

你剛學到:

  • 矩陣陣列 決定每實例的位置/旋轉/縮放。
  • MaterialPropertyBlock.SetVectorArray("_Color") 對應到 shader 裡的 Instanced _Color
  • 一次最多 1023,超過就切批。

6) 另一條路:很多個 Renderer 也能 Instancing(但不一定穩)

如果你有 1000 個 MeshRenderer 物件、共享同一個材質,而且材質支援 Instancing,Unity 有機會 自動把它們合在同一個 instanced draw 裡。
但只要中間出現不同關鍵字、Lightmap 索引、Shadow 設定、Render Queue…就會被拆掉。
**實務建議:**要穩定可控、大量物件,Graphics.DrawMeshInstanced


7) 快速檢查與除錯

  • Frame DebuggerWindow > Analysis > Frame Debugger,啟用看看是不是出現 Draw Mesh (Instanced)

  • Stats 視窗:看 Batches / SetPass calls 是否下降,Triangles 會差不多(因為還是畫那麼多面)。

  • 材質 Inspector:確認 Enable GPU Instancing 有勾(大多 shader 需要)。

  • 顏色沒生效?

    • 確認 shader 有 UNITY_DEFINE_INSTANCED_PROP(float4, _Color)
    • SetVectorArray("_Color", …) 的陣列長度要跟本批次實例數一致。
  • 沒合批?

    • Mesh / Material 必須完全相同;關鍵字/Queue/Blend 狀態不同會拆。
    • 光照/陰影/Lightmap/Probe/LOD 差異也可能拆批。先用最簡設定驗證。

8) 效能心法(初學者版)

  • 畫面外的別畫DrawMeshInstanced整批做裁切;若一批全部不在視錐外就不畫,但部分在內時整批都會送 GPU。把格子分區、離鏡頭遠的分成另一批。
  • 每實例資料要精簡:顏色、單一 float 都很便宜;大資料(例如骨骼矩陣)就不適合放 instanced prop。
  • 盡量共享材質:多一個材質等於多一種「狀態」,更難合批。
  • 別跟「動態合批(dynamic batching)」混淆:那是 CPU 合併網格,受頂點數限制;Instancing 是 GPU 端一次畫很多份,吞吐更好。
  • 更進階:要十萬級並且要做「每實例可見性/LOD 計算」,可研究 DrawMeshInstancedIndirect + Compute Shader(之後再玩)。

9) 五分鐘實驗清單(你會立刻有感)

  1. 32×32 → 64×64:把 countPerAxis 翻倍,看 Batches 幾乎沒變,但 Triangles 翻倍。
  2. 顏色陣列改成棋盤格:試著用 i%2 控制顏色,驗證每實例屬性真的各自生效。
  3. 旋轉動畫:每幀在 Update 裡把某個批次的矩陣乘上 Quaternion.Euler,觀察 GPU 負載與 CPU 穩定度。
  4. 拆材質:把一半使用 instancedMaterialA,另一半用 B,觀察 Batches 變多(證明「材質不同就拆批」)。
  5. 開影子:把 ShadowCastingMode.On 打開,再觀察批次變化與效能差異。

10) 一句話總結

GPU Instancing = 讓 GPU 一口氣畫同一套網格+材質的很多分身
透過「每實例矩陣+少量屬性」來做差異化。
寫一個支援 instancing 的 shader,再用 Graphics.DrawMeshInstanced 投出幾百幾千個物件,你就把 CPU 從「瘋狂下命令」中解放出來了。下一步想衝更大,才需要 DrawMeshInstancedIndirect 與 Compute 做可見性/LOD。祝你場景滿滿、FPS 穩穩!


上一篇
Day 8|Normal Map:讓平面有細節
下一篇
Day 10|Vulkan 是什麼?與 OpenGL 的差異與優勢(零基礎友善版)
系列文
渲染與GPU編程22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言